In Build a Roslyn Analyzer from scratch we scaffolded PSC.Analyzers.Blazor. Now we write
the rule: PSC001 — a Blazor component’s [Parameter] properties must be
PascalCase. [Parameter] properties are the component’s public API, so
PolicyId is right and policyId is not.
The full source code of the analyzer is available on GitHub.
The anatomy of an analyzer
Every analyzer is a class that:
- is decorated with
[DiagnosticAnalyzer(LanguageNames.CSharp)], - derives from
DiagnosticAnalyzer, - exposes the diagnostics it can produce via
SupportedDiagnostics, - registers callbacks in
Initialize.
Let’s build each piece.
Describe the diagnostic
A DiagnosticDescriptor is the definition of a rule — its id, its message, its
default severity. You create one static, reusable instance:
private static readonly DiagnosticDescriptor Rule = new(
id: "PSC001",
title: "Component parameter should be PascalCase",
messageFormat: "Component parameter '{0}' should be PascalCase",
category: "PSC.Blazor",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "[Parameter] properties are part of a component's public API and must use PascalCase.");
The {0} in messageFormat is filled in when you report the diagnostic, so each
message names the offending property. defaultSeverity is Warning — a good
default; consumers can dial it up to error or down to suggestion in their
.editorconfig.
Choosing what to analyze
Roslyn lets you register callbacks against different granularities:
- Syntax nodes — raw source shapes (an
ifwith no braces). Fast, but no type information. - Symbols — semantic declarations (a method, a property, a type). This is where most rules live.
- Operations — the semantic meaning of code inside a method (a call, a property
access). Used for things like “don’t call
.Result”.
PSC001 is about a property declaration and whether it carries the [Parameter]
attribute, so we register a symbol action on properties.
Resolving Blazor types
Here’s the catch: our analyzer targets netstandard2.0 and does not reference
Microsoft.AspNetCore.Components. So how do we recognise [Parameter]? We ask the
compilation being analyzed for the type by its full metadata name:
var parameterAttribute = compilation
.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ParameterAttribute");
If it returns null, the project doesn’t reference Blazor — so there’s nothing for
us to do, and the analyzer becomes a no-op. This is the idiomatic way to write
framework-specific rules without taking a dependency on that framework.
We do this lookup once per compilation using RegisterCompilationStartAction, then
register the per-symbol work inside it. That avoids repeating the lookup for every
property in the project.
The full analyzer
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
namespace PSC.Analyzers.Blazor;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ParameterNamingAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "PSC001";
private static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: "Component parameter should be PascalCase",
messageFormat: "Component parameter '{0}' should be PascalCase",
category: "PSC.Blazor",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "[Parameter] properties are part of a component's public API and must use PascalCase.");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
// Blazor compiles @code blocks into generated C#; analyse it too so
// parameters declared in .razor files are covered.
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(OnCompilationStart);
}
private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
var parameterAttribute = context.Compilation
.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ParameterAttribute");
// Not a Blazor project — nothing to do.
if (parameterAttribute is null)
{
return;
}
context.RegisterSymbolAction(
symbolContext => Analyze(symbolContext, parameterAttribute),
SymbolKind.Property);
}
private static void Analyze(SymbolAnalysisContext context, INamedTypeSymbol parameterAttribute)
{
var property = (IPropertySymbol)context.Symbol;
// The name is fixed by a base type — we can't fix it here.
if (property.IsOverride)
{
return;
}
var isParameter = property.GetAttributes().Any(attribute =>
SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, parameterAttribute));
if (!isParameter)
{
return;
}
var name = property.Name;
if (name.Length == 0 || char.IsUpper(name[0]))
{
return;
}
context.ReportDiagnostic(Diagnostic.Create(Rule, property.Locations[0], name));
}
}
Reading the logic
The Analyze method is a series of cheap early-exits — a good habit, since it runs
for every property in the project:
- Skip
overrideproperties — their name is inherited and can’t be changed here. - Skip properties without
[Parameter]— we compare each attribute’s class to theParameterAttributesymbol usingSymbolEqualityComparer, never by string. - If the name already starts with an uppercase letter, it’s fine.
- Otherwise, report the diagnostic at the property’s location, passing
nameto fill the{0}in the message.
Diagnostic.Create(Rule, property.Locations[0], name) places the squiggle on the
property identifier — exactly where the developer needs to see it.
A note on Initialize
Three lines you’ll write in almost every analyzer:
ConfigureGeneratedCodeAnalysis(...)— for Blazor we chooseAnalyze, because a component’s@codeblock is compiled into generated C#, and we want to catch parameters declared there too. Many analyzers useNoneto skip generated code.EnableConcurrentExecution()— lets the host run your analyzer in parallel. Safe because our callbacks are static and stateless.RegisterCompilationStartAction(...)— our per-compilation setup.
Try it out
Reference the analyzer project from a Blazor app as an analyzer:
<ProjectReference Include="..\PSC.Analyzers.Blazor\src\PSC.Analyzers.Blazor\PSC.Analyzers.Blazor.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Add a component with a bad parameter and build:
[Parameter] public int policyId { get; set; } // PSC001
You’ll see warning PSC001: Component parameter 'policyId' should be PascalCase.
The rule works — but “it worked when I tried it” isn’t a test suite. In Part 3 we write automated tests that prove the analyzer fires on the right code and, just as importantly, stays silent on the right code.